<!--
  - 二维卷积神经网络的模型训练
  - 模型：新建模型是用tf.model方法，定义input和output；层则用apply()实现衔接
  - 数据集：拆分了数据集内容（图片）和数据集字典文件（excel表格）。
  -   直接读取文件夹内容，获取所有File对象，然后用字典文件校对。
  -   校对时也处理File为<img>的HTMLElement对象，便于tf读取。
  -   训练集用的tf.Dataset类，所以设计了迭代生成器，把HTMLElement转为Tensor。
  - 训练：比较简单，具体见页面。
 -->

<!-- 视图层 -->
<template>

<!-- TFjs初始化组件 -->
<tfjsInit />

<!-- 功能简介 -->
<uni-card title="功能简介">
  <!-- 通告栏 -->
  <uni-notice-bar text=
    "操作方法：
    ① 模型：可以直接新建模型（从头训练），也可以读取先前下载至本地的模型（继续训练）。
    ② 数据集：将全部数据照片放进本地文件夹。读取xlsx格式标签文件、照片文件夹。
    ③ 训练：设定迭代次数，即可训练。训练完成后可获取拟合记录。
    ④ 结果：可下载训练后的模型（含拓扑结构文件、权重文件）、训练历史记录。"
  />
  <!-- 文字 -->
  <view><text>
    　　立足接触角测量的业务需求，设计了接触角照片-接触角的卷积神经网络模型。\n
    　　具体介绍待完善。
  </text></view>
  <!-- 二级标题：参考文献 -->
  <uni-section
    title="参考库链接："
    type="circle"
    padding="0 1em"
  >
    <!-- 链接：库 -->
    <uni-link
      text="https://js.tensorflow.org/"
      href="https://js.tensorflow.org/api/latest/"
      class="center" color="blue"
    />
  </uni-section>
</uni-card>

<!-- 模型 -->
<uni-card title="模型">
  <!-- 通告栏 -->
  <uni-notice-bar text=
    "可以直接新建模型（从头训练），也可以读取先前下载至本地的模型（继续训练）。"
  />
  <!-- 二级标题：新建模型 -->
  <uni-section
    title="新建模型"
    type="circle"
    padding="0 1em"
  >
    <!-- 文字 -->
    <view><text>
      　　模型已优化，具体参数详见模型概述。
    </text></view>
    <!-- 建立新模型/打开图表板-容器 -->
    <view class="center">
      <!-- 建立新模型按钮 -->
      <button
        :disabled="isGetModel || !tfBackendRef"
        @click="buildNewModel()"
        size="mini" type="primary"
      >
        {{ isGetModel ? "已建立/读取模型" : "新建模型" }}
      </button>
      <!-- 打开图表板按钮 -->
      <button
        v-if="isGetModel"
        @click="showVisor()"
        size="mini" type="primary"
      >
        查看模型概述
      </button>
    </view>
  </uni-section>
  <!-- 二级标题：读取已有模型 -->
  <uni-section
    title="读取已有模型"
    type="circle"
    padding="0 1em"
  >
    <!-- 文字 -->
    <view><text>
      　　需要拓扑结构文件和权重参数文件。\n
      　　注意：本应用生成的拓扑结构文件，其匹配的权重参数文件名应为“model.weights.bin”，千万不要修改文件名，以免校验报错。
    </text></view>
    <!-- 上传模型文件容器 -->
    <view class="center">
      <!-- 拓扑结构文件 -->
      <uni-file-picker
        class="center"
        :disabled="isGetModel || !tfBackendRef"
        :readonly="isGetModel"
        file-extname="json" fileMediatype="all"
        limit="1" return-type="object" :del-icon="false"
        :auto-upload="false" :disable-preview="true"
        @select="(event) => readModelFile(event.tempFiles[0])"
      >
        <!-- 文件上传按钮 -->
        <button size="mini" type="primary">
          读取拓扑文件
        </button>
      </uni-file-picker>
      <!-- 权重参数文件 -->
      <uni-file-picker
        class="center"
        :disabled="isGetModel || !tfBackendRef"
        :readonly="isGetModel"
        file-extname="bin" fileMediatype="all"
        limit="1" return-type="object" :del-icon="false"
        :auto-upload="false" :disable-preview="true"
        @select="(event) => readModelFile(event.tempFiles[0])"
      >
        <!-- 文件上传按钮 -->
        <button size="mini" type="primary">
          读取权重文件
        </button>
      </uni-file-picker>
    </view>
  </uni-section>
</uni-card>

<!-- 数据集。条件渲染：无模型 -->
<uni-card
  v-if="!isGetModel"
  title="数据集"
>
  <!-- 通告栏 -->
  <uni-notice-bar text=
    "将全部数据照片放进本地文件夹。读取xlsx格式标签文件、照片文件夹。
    需先建立/读取模型，才能挂载数据集。"
  />
</uni-card>

<!-- 数据集。条件渲染：有模型，可执行数据集和训练 -->
<uni-card
  v-else
  title="数据集"
>
  <!-- 通告栏 -->
  <uni-notice-bar text=
    "将全部数据照片放进本地文件夹。读取xlsx格式标签文件、照片文件夹。"
  />

  <!-- 二级标题：xlsx标签文件 -->
  <uni-section
    title="xlsx标签文件"
    type="circle"
    padding="0 1em"
  >
    <!-- 文字 -->
    <view><text>
      　　xlsx标签文件格式：\n
      　　一共两列即可，第一列是文件名（不包含文件名后缀），第二列是标签值（即接触角值）。\n
      　　xlsx文件可包含标题行（即第一行是“文件名”、“标签值”，第二行开始是数据内容），也可不含标题行（即第一行就是数据内容），会自动识别。
    </text></view>
    <!-- 文件上传容器 -->
    <uni-file-picker
      class="center"
      :readonly="isGetDataset"
      file-extname="xlsx, xls" fileMediatype="all"
      limit="1" return-type="object" :del-icon="false"
      :auto-upload="false" :disable-preview="true"
      @select="(event) => readDataIndexFile(event.tempFiles[0].file)"
    >
      <!-- 文件上传按钮 -->
      <button size="mini" type="primary">
        读取xlsx标签文件
      </button>
    </uni-file-picker>
  </uni-section>
  <!-- 二级标题：照片文件夹 -->
  <uni-section
    title="照片文件夹"
    type="circle"
    padding="0 1em"
  >
    <!-- 文字 -->
    <view><text>
      　　需要拓扑结构文件和权重文件。\n
      　　不然模型缺少参数，相当于新模型了。
    </text></view>
    <!-- 读取照片文件夹按钮及容器 -->
    <view class="center"><button
      :disabled="isGetDataset"
      @click="readDataDirectory()"
      size="mini" type="primary"
    >
      {{ isGetDataset ? "已获得数据集" : "挂载照片文件夹" }}
    </button></view>
  </uni-section>
</uni-card>

<!-- 训练。条件渲染：无模型 -->
<uni-card
  v-if="!isGetModel"
  title="训练"
>
  <!-- 通告栏 -->
  <uni-notice-bar text=
    "可设定批次样本数、迭代次数、训练集乱序、验证集等。训练完成后可获取拟合记录。
    需先建立/读取模型，才能训练。"
  />
</uni-card>

<!-- 训练。条件渲染：有模型，可执行数据集和训练 -->
<uni-card
  v-else
  title="训练"
>
  <!-- 通告栏 -->
  <uni-notice-bar text=
    "可设定批次样本数、迭代次数、训练集乱序、验证集等。训练完成后可获取拟合记录。"
  />
  <!-- 二级标题：参数说明 -->
  <uni-section
    title="参数说明"
    type="circle"
    padding="0 1em"
  >
    <!-- 折叠面板及下拉框 -->
    <uni-collapse><uni-collapse-item
      title="点击下拉查看参数说明"
    >
      <!-- 二级标题：批次样本数 -->
      <uni-section
        title="批次样本数"
        type="line"
        padding="0 1em"
      >
        <!-- 文字 -->
        <view><text>
          　　批次样本数，即batchSize。\n
          　　海量的数据集不可能一次性全被读取进GPU缓存区，所以数据其实是“一批一批地”被带入模型训练的。每一批数据的大小，即批次样本数。\n
          　　批次样本数一般为2的整数幂（如32、16、8、4等），以对齐运算资源，优化计算效率。一般而言，批次样本数越大，训练完一轮全部数据的速度就越快，当然也就越消耗内存。\n
          　　建议打开任务管理器，关注训练时的内存开销，选择内存占用适中的批次样本数。
        </text></view>
      </uni-section>
      <!-- 二级标题：迭代次数 -->
      <uni-section
        title="迭代次数"
        type="line"
        padding="0 1em"
      >
        <!-- 文字 -->
        <view><text>
          　　迭代次数，即epochs。\n
          　　神经网络遵循以学习率为超参的迭代学习规则，仅仅遍历一轮全部训练集数据很难达到令人满意的训练效果。合适的模型往往需要使用全部训练集数据进行重复上千次的训练拟合。\n
          　　正式训练时的迭代次数一般为某个整数（如100、数百、1000等）。迭代次数越大，训练时间就越长，也越容易达到令人满意的训练效果，但也可能造成过拟合。\n
          　　正式训练前，也往往先用较小的迭代次数（如10、20、50等）尝试训练，观察可能的计算性能、拟合变化等情况，以更好的估算迭代次数。批次样本数越大，训练完一轮全部数据的速度就越快，当然也就越消耗内存。
        </text></view>
      </uni-section>
      <!-- 二级标题：训练集乱序 -->
      <uni-section
        title="训练集乱序"
        type="line"
        padding="0 1em"
      >
        <!-- 文字 -->
        <view><text>
          　　训练集乱序可防止训练集自身因数据排序而存在的“序列规律”导致的过拟合。
        </text></view>
      </uni-section>
      <!-- 二级标题：验证集提取间隔 -->
      <uni-section
        title="验证集提取间隔"
        type="line"
        padding="0 1em"
      >
        <!-- 文字 -->
        <view><text>
          　　一般而言，会从数据集中划分出一部分数据作为验证集，用于在训练过程中评估模型的泛化能力。验证集的数据不会在训练集中出现。\n
          　　因本项目数据序列具有一定规律，故提取验证集的算法为：规定[验证集提取间隔]，每隔一定间隔从数据集中提取一个样本，作为验证集。间隔越大，验证集越少，反之越多。\n
          　　当[验证集提取间隔]为0时，不提取验证集。
        </text></view>
      </uni-section>
    </uni-collapse-item></uni-collapse>
  </uni-section>
  <!-- 二级标题：调参训练 -->
  <uni-section
    title="调参训练"
    type="circle"
    padding="0 1em"
  >
    <!-- 输入框的表格容器 -->
    <uni-table
      :border="true" :stripe="true"
    >
      <!-- 标题行 -->
      <uni-tr>
        <uni-th align="center">
          键
        </uni-th>
        <uni-th align="center">
          值
        </uni-th>
      </uni-tr>
      <!-- 内容行：批次样本数 -->
      <uni-tr>
        <uni-td align="center">
          批次样本数
        </uni-td>
        <!-- 批次样本数输入框及容器 -->
        <uni-td><uni-easyinput
          v-model="batches"
          type="number"
          placeholder="请输入批次样本数"
          trim="all" :clearable="false"
        /></uni-td>
      </uni-tr>
      <!-- 内容行：迭代次数 -->
      <uni-tr>
        <uni-td align="center">
          迭代次数
        </uni-td>
        <!-- 迭代次数输入框及表格容器 -->
        <uni-td><uni-easyinput
          v-model="epochCounts"
          type="number"
          placeholder="请输入迭代次数"
          trim="all" :clearable="false"
        /></uni-td>
      </uni-tr>
      <!-- 内容行：训练集乱序 -->
      <uni-tr>
        <uni-td align="center">
          训练集乱序
        </uni-td>
        <!-- 是否乱序开关及表格容器 -->
        <uni-td align="center">
          否　<switch
            :disabled="isOnTrain"
            :checked="isShuffle"
            @change="(event) => { isShuffle = event.detail.value }"
            type="switch"
          />　是
        </uni-td>
      </uni-tr>
      <!-- 内容行：验证集提取间隔 -->
      <uni-tr>
        <uni-td align="center">
          验证集提取间隔
        </uni-td>
        <!-- 验证集提取间隔输入框及表格容器 -->
        <uni-td><uni-easyinput
          v-model="valPerCount"
          type="number"
          placeholder="请输入验证集提取间隔"
          trim="all" :clearable="false"
        /></uni-td>
      </uni-tr>
    </uni-table>
    <!-- 训练按钮容器 -->
    <view class="center">
      <!-- 训练 -->
      <button
        :disabled="!isGetDataset || !isGetModel || isOnTrain || isTrained"
        @click="trainModel()"
        size="mini" type="primary"
      >
        训练模型
      </button>
      <!-- 停止训练 -->
      <button
        v-if="isOnTrain"
        :disabled="isStopTrain"
        @click="() => { isStopTrain = true }"
        size="mini" type="primary"
      >
        停止训练
      </button>
    </view>
  </uni-section>
  <!-- 二级标题：训练记录 -->
  <uni-section
    v-if="logArrRef[0]"
    title="计算历程"
    type="circle"
    padding="0 1em"
  >
    <!-- 计算历程：表格容器 -->
    <uni-table border stripe>
      <!-- 表格标题行 -->
      <uni-tr>
        <uni-th align="center">序号</uni-th>
        <uni-th>内容</uni-th>
      </uni-tr>
      <!-- 表格内容行 -->
      <uni-tr v-for="(log, index) in logArrRef" :key="index">
        <uni-td align="center">{{ index + 1 }}</uni-td>
        <uni-td><text>{{ log }}</text></uni-td>
      </uni-tr>
    </uni-table>
    <!-- 打开图表板容器及按钮 -->
    <view class="center"><button
      @click="showVisor()"
      size="mini" type="primary"
    >
      打开图表板
    </button></view>
  </uni-section>
  <!-- 二级标题：模型下载 -->
  <uni-section
    title="模型下载"
    type="circle"
    padding="0 1em"
  >
    <!-- 下载模型容器及按钮 -->
    <view class="center"><button
      @click="downloadModel()"
      size="mini" type="primary"
    >
      下载模型[拓扑结构]&[权重参数]
    </button></view>
    <!-- 下载训练记录容器及按钮 -->
    <view
      v-if="isTrained"
      class="center"
    >
      <button
        @click="saveTrainLog()"
        size="mini" type="primary"
      >
        下载训练历程数据
      </button>
    </view>
  </uni-section>
</uni-card>

<!-- 页脚容器 -->
<view class="center"><text>
  \n
  参赛作品-二维卷积神经网络的模型训练
</text></view>

</template>


<!-- 逻辑层 -->
<script setup>
// 导入vue的框架方法
import { shallowRef, ref } from "vue"
// 导入tfjs和tfjs-visor库
import * as tf from "@tensorflow/tfjs"
import * as tfvis from "@tensorflow/tfjs-vis"
// 导入xlsx库，以XLSX作为引用对象
import * as XLSX from "xlsx"
// 导入TF初始化组件
import tfjsInit from "../index/tfjs-init.vue"
// 导入公共方法库的方法
import {
  // tfjs计算后端挂载完毕的Ref对象
  tfBackendRef,
  // 显示图表板的方法
  showVisor,
  // 读取xlsx文件为workbook对象
  readXlsxFile,
  // 数组转置
  arrayTranspose,
  // 下载训练历程到xlsx文件
  downloadTrainHistoryToXlsx,
  // 可视化模型概要
  showModelSummary,
  // 可视化模型各层
  showModelLayers
} from "../../scripts/app_common.js"
// 导入模型方法
import { createModel } from "../../scripts/models/contact-angle.js"

/**
 * @ref对象
 */
// 训练记录
const logArrRef = ref([])
// 是否获取模型
const isGetModel = shallowRef(false)
// 是否获取数据集
const isGetDataset = shallowRef(false)
// 是否正在训练
const isOnTrain = shallowRef(false)
// 是否训练
const isTrained = shallowRef(false)
// 批次样本数
const batches = shallowRef(4)
// 训练迭代次数
const epochCounts = shallowRef(10)
// 每多少个数据提取一个验证集
const valPerCount = shallowRef(10)
// 是否乱序
const isShuffle = shallowRef(true)
// 是否停止训练
const isStopTrain = shallowRef(false)

/**
 * @其他对象
 */
// 模型
const modelObj = {
  // 模型
  model: null,
  // 输入文件的高
  inputHeight: null,
  // 输入文件的宽
  inputWidth: null,
  // 模型拓扑结构文件
  modelJsonFile: null,
  // 模型权重参数文件
  modelWeightsFile: null,
}
// 数据集
const datasetObj = {
  // 文件名集，是个由文件名组成的数组，用于fileMapObj的索引
  fileNameArr: null,
  // 文件Map集，是个由"fileName"-"file"键值对作为成员对象的对象
  fileMapObj: null,
  // 标签集，是个由标签组成的数组。与fileNameArr文件名集一一对应
  labelArr: null,
  // 图片元素集，是个由<img>元素对象组成的数组。与fileNameArr文件名集一一对应
  imgElementArr: null,
  // 训练时候遍历用的训练集数组字典，如果乱序，可以很快打散作为字典
  indexArr: null,
  // 训练时候遍历用的验证集数组字典
  valIndexArr: null,
}
// 训练
const trainObj = {
  // 记录
  log: null
}

/**
 * @buildNewModel 建立新的模型
 * @function
 */
async function buildNewModel() { try {
  // 直接通过导入方法建立模型
  const model = createModel()
  // 打印模型结构
  model.summary()
  showModelSummary(model, "新建模型概述")
  // 输出模型及参数
  modelObj.model = model
  modelObj.inputHeight = model.inputs[0].shape[1]
  modelObj.inputWidth = model.inputs[0].shape[2]
  // 已获取模型标签
  isGetModel.value = true
} catch (error) {
  console.error("buildNewModel()报错: ", error)
  uni.showModal({
    showCancel: false,
    title: "建立模型报错",
    content: error,
  })
}}

/**
 * @readModelFile 读取模型(文件)
 * 包含读取JSON文件和权重文件
 * @function
 * @param {{
 *   file: File
 *   extname: String
 * }} fileMapObj 封装的文件对象
 */
async function readModelFile(fileMapObj) { try {
  // 显示加载圈
  uni.showLoading({ title: "正在读取文件..." })
  // 根据文件后缀名，分配拓扑文件和权重文件
  if (fileMapObj.extname === "json") {
    modelObj.modelJsonFile = fileMapObj.file
  } else if (fileMapObj.extname === "bin") {
    modelObj.modelWeightsFile = fileMapObj.file
  }
  // 如果两个文件都传了，就开始解析
  if (modelObj.modelJsonFile && modelObj.modelWeightsFile) {
    const model = await tf.loadLayersModel(
      tf.io.browserFiles([modelObj.modelJsonFile, modelObj.modelWeightsFile])
    )
    // 可视化模型概要
    showModelSummary(model, "已有模型概述")
    // showModelLayers(model, "模型参数")
    // 输出模型参数
    modelObj.model = model
    modelObj.inputHeight = model.inputs[0].shape[1]
    modelObj.inputWidth = model.inputs[0].shape[2]
    // 删掉文件对象节省内存
    modelObj.modelJsonFile = null
    modelObj.modelWeightsFile = null
    // 已获取模型标签
    isGetModel.value = true
  }
  // 隐藏加载圈
  uni.hideLoading()
} catch (error) {
  console.error("readModelFile()报错: ", error)
  uni.hideLoading()
  uni.showModal({
    showCancel: false,
    title: "读取模型(文件)报错",
    content: error,
  })
}}

/**
 * @readDataIndexFile 读取数据标签文件
 * excel表格，包含数据文件名和标签
 * @function
 * @param {{ File }} file 封装的文件对象
 */
async function readDataIndexFile(file) { try {
  // 显示加载圈
  uni.showLoading({ title: "正在读取文件数据" })
  // 先清零旧的datasetObj里关于文件名标签的数据
  datasetObj.fileNameArr = null
  datasetObj.labelArr = null
  // 读取文件为工作表对象
  const workbook = await readXlsxFile(file)
  // 将工作表转为AOA数据
  const sheetDataAoa = XLSX.utils.sheet_to_json(
    workbook.Sheets[workbook.SheetNames[0]],
    {
      // 标题设置为“1模式”，即AOA呈现
      header: 1,
      // 不允许空白行，遇空白行则跳过
      blankrows: false
    }
  )
  // sheetDataAoa转置
  // 如果第一行标签的内容项不是数字，则从第二行开始转置，否则从第一行开始转置
  const sheetDataAoaT =
    (sheetDataAoa[0][1] && isNaN(Number(sheetDataAoa[0][1])))
      ?
    arrayTranspose(sheetDataAoa.slice(1))
      :
    arrayTranspose(sheetDataAoa)
  // 建一个接收转置数值的筐
  // 数据文件名筐
  const fileNameArr = []
  // 标签数据集的筐
  const labelArr = []
  // 遍历转之后的数据
  sheetDataAoaTForEach: for (let i = 0; i < sheetDataAoaT[0].length; i++) {
    // 如果有任何一个数据为空，则跳过
    if (!sheetDataAoaT[0][i] || !sheetDataAoaT[1][i]) {
      continue sheetDataAoaTForEach
    }
    // 接标签
    const label = Number(sheetDataAoaT[1][i])
    // 如果标签不是数字，则跳过
    if (isNaN(label)) {
      continue sheetDataAoaTForEach
    }
    // 把数据整理好推进筐里
    // 文件名
    fileNameArr.push(sheetDataAoaT[0][i])
    // 标签数据
    labelArr.push(label)
  }
  // 遍历结束。把数据传出去
  datasetObj.labelArr = labelArr
  datasetObj.fileNameArr = fileNameArr
  // 检查文件有效性
  isGetDataset.value = await fileCheck()
  // 隐藏加载圈
  uni.hideLoading()
} catch (error) {
  console.error("readDataIndexFile报错: ", error)
  uni.hideLoading()
  uni.showModal({
    showCancel: false,
    title: "读取数据标签文件报错",
    content: error,
  })
}}

/**
 * @readDataDirectory 读取(图片)数据文件夹
 * 内容为各类图片文件
 * @function
 */
async function readDataDirectory() { try {
  // 显示加载圈
  uni.showLoading({ title: "正在读取文件数据" })
  // 先清零旧的datasetObj.file数据
  datasetObj.fileMapObj = null
  // 建一个对象，用来装句柄内容
  const fileMapObj = {}
  // 打开文件夹，返回句柄。句柄成员对象：kind、name
  const dirHandle = await window.showDirectoryPicker()
  // 处理句柄，得到异步迭代器
  const asyncIter = dirHandle.entries()
  for await (const [fileName, fileHandle] of asyncIter) {
    // 按“.”拆分、去掉最后一个（去掉扩展名），再按“.”合并，得到文件名
    const fileNamePure = fileName.split(".").slice(0, -1).join(".")
    // 建立文件名与文件的映射关系
    fileMapObj[fileNamePure] = await fileHandle.getFile()
  }
  // 把fileObj传出去
  datasetObj.fileMapObj = fileMapObj
  // 检查文件有效性
  isGetDataset.value = await fileCheck()
  // 隐藏加载圈
  uni.hideLoading()
} catch (error) {
  console.error("readDataDirectory()报错: ", error)
  uni.hideLoading()
  uni.showModal({
    showCancel: false,
    title: "读取(图片)数据文件夹报错",
    content: error,
  })
}}

/**
 * @fileCheck 文件有效性核验
 * @function
 * @returns { Promise<Boolean> } true: 有效; false: 无效
 * @note 会读取datasetObj的indexArr、fileNameArr和fileMapObj，写入imgElementArr
 */
async function fileCheck() { try {
  // 先看看datasetObj的fileNameArr和fileMapObj有没有内容
  if (!datasetObj.fileMapObj || !datasetObj.fileNameArr) {
    // 只要任何一个没有内容，就直接返回false
    return false
  }
  // 更新加载圈
  uni.showLoading({ title: "正在校验文件数据" })
  // 把旧<img>元素数组清空，回收内存
  datasetObj.imgElementArr = null
  // 把旧的乱序字典数组indexArr清空，回收内存
  datasetObj.indexArr = null
  // 接收<img>元素的筐
  const imgElementArr = []
  // 训练用的迭代器乱序字典数组
  const indexArr = []
  // 获取遍历次数
  const fileCounts = datasetObj.fileNameArr.length
  // 遍历datasetObj.fileNameArr
  for (let i = 0; i < fileCounts; i++) {
    // 接收文件名
    const fileName = datasetObj.fileNameArr[i]
    // 找文件
    const file = datasetObj.fileMapObj[fileName]
    // 如果indexArr中某个文件名在fileMapObj中不存在，则报错
    if (!file) {
      // 报错
      console.log("fileCheck()检查出问题: ", fileName, "不存在")
      uni.showModal({
        showCancel: false,
        title: "文件核验出错",
        content: `未找到${ fileName }文件`,
      })
      return false
    // 如果文件存在
    } else {
      // 得到文件url
      const fileUrl = URL.createObjectURL(file)
      // 构造<img>元素
      const imgElement = new Image()
      // 挂载图片
      imgElement.src = fileUrl
      // 等待挂载解码完成
      await imgElement.decode()
      // 修改<img>元素的宽高以让长边符合要求
      if (
        (imgElement.naturalWidth / modelObj.inputWidth) 
        >= (imgElement.naturalHeight / modelObj.inputHeight)
      ) {
        imgElement.width = modelObj.inputWidth
        imgElement.height = imgElement.naturalHeight 
          / imgElement.naturalWidth * modelObj.inputHeight
      } else {
        imgElement.height = modelObj.inputHeight
        imgElement.width = imgElement.naturalWidth 
          / imgElement.naturalHeight * modelObj.inputWidth
      }
      // 等待挂载解码完成
      await imgElement.decode()
      // 把<img>元素推入imgElementArr
      imgElementArr.push(imgElement)
      // 把索引推入indexArr
      indexArr.push(i)
    }
  }
  // 遍历结束，导出<img>元素筐
  datasetObj.imgElementArr = imgElementArr
  // 乱序字典数组
  datasetObj.indexArr = indexArr
  // 清空过程筐以回收内存：文件名数组、文件名映射对象
  datasetObj.fileNameArr = null
  datasetObj.fileMapObj = null
  // 返回true
  return true
} catch (error) {
  console.error("fileCheck()报错: ", error)
}}

/**
 * @trainModel 训练模型
 * @function
 * @note 会读取模型modelObj.model；
 *   训练集tensorObj.trainSet、标签集tensorObj.labelSet；
 *   迭代次数epochCounts、批次batches、训练日志logTrainArr、训练状态isOnTrain；
 * @note 为了确保性能，不用try-catch包裹
 */
async function trainModel() {
  // ref标记
  isOnTrain.value = true
  // 编译模型
  modelObj.model.compile({
    // 优化器：Adam
    optimizer: tf.train.adam(),
    // 损失函数：均方误差
    loss: tf.losses.meanSquaredError,
    // 评估指标：平均绝对误差
    metrics: [tf.metrics.meanAbsoluteError],
  })
  // 接批次样本数batches的值
  const batchesValue = 
    (Math.round(batches.value) > 0) ?
    Math.round(batches.value) : 4
  // 接迭代次数epochCounts的值
  const epochCountsValue = (Math.round(epochCounts.value) > 0) ?
    Math.round(epochCounts.value) : 10
  // 接收验证集处理间隔valPerCount的值
  const valPerCountValue = (Math.round(valPerCount.value) > 0) ?
    Math.round(valPerCount.value) : 0
  // 如果用户设置了验证集处理间隔
  if (valPerCountValue > 0) {
    // 则获取验证集数组（同时从数据集中扣除验证集数组，得到真·训练集）
    getValIndex(valPerCountValue)
  }
  // 从迭代器获取dataset训练数据集
  const dataset = tf.data.generator(getTrainTensorIter)
    // 链式调用：设定批次，并以批次为缓冲区
    .batch(batchesValue).prefetch(batchesValue)
  // 若datasetObj.valIndexArr验证集数组有数据
  const valDataset = (datasetObj.valIndexArr) ?
    // 则声明一个迭代器获取验证数据集valDataset
    tf.data.generator(getValTensorIter)
      // 链式调用：设定批次，并以批次为缓冲区
      .batch(batchesValue).prefetch(batchesValue)
    // 若验证集数组没数据，则验证数据集valDataset为undefined
    : undefined
  // 搞个时间戳，用于计算训练时间
  const startTime = (new Date()).getTime()
  // 记录开始
  logTrainArr(
    `训练集样本数：${ datasetObj.indexArr.length }
    验证集样本数：${ datasetObj.valIndexArr ? datasetObj.valIndexArr.length : 0 }
    开始训练模型。`
  )
  // 以dataset数据集训练模型
  trainObj.log = await modelObj.model.fitDataset(dataset, {
    // 迭代次数，即训练轮数
    epochs: epochCountsValue,
    // // 每轮迭代训练的批次大小，对于无限的数据集有用
    // batchesPerEpoch: undefined,
    // 日志选项。0即静默模式，不打印日志，适用于浏览器环境
    verbose: 0,
    // 验证集
    validationData: valDataset,
    // 回调方法
    callbacks: [
      // 每轮训练结束的回调
      { onEpochEnd: (epoch, logs) => {
        // 生成记录内容
        // console.log(`完成${ epoch + 1 }轮`)
        let log =
          `已完成 ${ epoch + 1 } / ${ epochCountsValue } 轮迭代：
          训练集损失值 = ${ logs.loss }`
        // 如果验证集存在，就补充数据
        if (logs.val_loss !== undefined) {
          log = log + `
            验证集损失值 = ${ logs.val_loss }`
          if (logs.loss > logs.val_loss) {
            log = log + "\n迭代正常"
          } else {
            log = log + "\n！请注意过拟合情况"
          }
        }
        // 呈现记录
        logTrainArr(log)
        // 条件判断：停止训练
        if (isStopTrain.value === true) {
          // 停止训练
          modelObj.model.stopTraining = true
        }
      }}
    ]
  // 捕获错误信息
  }).catch((err) => {
    console.log("模型训练报错：", err)
    logTrainArr(
      `模型训练出错！
      ${ err }`
    )
    return false
  })
  // 训练结束，计算训练时间
  const endTime = (new Date()).getTime()
  // 如果训练日志存在
  if (trainObj.log) {
    // 训练过程的控制台记录 + 前端记录
    // console.log(`训练结束，记录为：${ JSON.stringify(trainLog) }`)
    logTrainArr(`模型训练结束，耗时 ${ (endTime - startTime) / 1000 } 秒，详情见图表。`)
    // 可视化模型各层参数
    showModelLayers(modelObj.model, "训练参数")
    // 可视化模型训练过程
    tfvis.show.history(
      // 容器
      {
        tab: "训练历程",
        name: "评价指标：均方误差 & 平均绝对误差（训练集 & 验证集）",
      },
      // 数据内容
      trainObj.log,
      // 数据分组
      trainObj.log.params.metrics,
    )
    // 打开图表板
    showVisor()
    // ref标记
    isTrained.value = true
  // 如果训练日志不存在，即训练失败
  } else {
    logTrainArr(`模型训练终止（失败），请刷新页面后重试。`)
  }
  // ref标记
  isOnTrain.value = false
}

/**
 * @getValIndex 获取验证集索引
 * @function
 * @param { Number } valPerCountValue 提取间隔
 * @note 会读写datasetObj.indexArr，写入datasetObj.valIndexArr
 */
function getValIndex(valPerCountValue) {
  // 接收数据集索引数组
  const allIndexArr = datasetObj.indexArr
  // 把旧的乱序字典数组indexArr和valIndexArr清空，回收内存
  datasetObj.indexArr = null
  datasetObj.valIndexArr = null
  // 声明验证集索引数组
  const valIndexArr = []
  // 开始遍历allIndexArr数组
  for (let i = 0; i <= allIndexArr.length - valPerCountValue; i = i + valPerCountValue) {
    // 计算出本次间隔内的随机数索引
    const count = Math.floor(Math.random() * valPerCountValue) + i
    // 把随机数索引推入valIndexArr
    valIndexArr.push(count)
  }
  // 从训练集中扣除验证集：先获取验证集索引
  const valIndexSet = new Set(valIndexArr)
  // 从训练集中扣除验证集，得到新的训练集索引
  const newIndexArr = allIndexArr.filter((index) => {
    return !valIndexSet.has(index)
  })
  // 更新乱序字典数组和验证集索引数组
  datasetObj.indexArr = newIndexArr
  if (valIndexArr.length > 0) {
    datasetObj.valIndexArr = valIndexArr
  }
}

/**
 * @downloadModel 保存模型
 * @function
 */
function downloadModel() { try {
  // 直接调用现成方法
  modelObj.model.save("downloads://model")
} catch (error) {
  console.error("saveTrainLog()报错: ", error)
  uni.showModal({
    showCancel: false,
    title: "保存训练记录报错",
    content: error,
  })
}}

/**
 * @saveTrainLog 保存训练记录
 * @function
 */
function saveTrainLog() { try {
  // 直接调用现成方法
  downloadTrainHistoryToXlsx(trainObj.log)
} catch (error) {
  console.error("saveTrainLog()报错: ", error)
  uni.showModal({
    showCancel: false,
    title: "保存训练记录报错",
    content: error,
  })
}}

/**
 * @下面的都是训练模型用到的方法
 * 为了确保性能，都没用try-catch包裹
 */

/**
 * @fileToTensorIter 将文件转换为张量的迭代器
 * @function 迭代器/生成器
 * @note 会读取datasetObj的indexArr、fileArr，isShuffle
 */
function* getTrainTensorIter() {
  // 看看是否需要乱序
  if (isShuffle.value) {
    // 如果需要打乱顺序，则打乱indexArr
    indexArrShuffle()
  }
  // 指针
  let trainPointer = 0
  // 生成器循环周期
  while (trainPointer < datasetObj.indexArr.length) {
    // 用自增pionter从训练集字典数组获取索引，进而获得张量
    yield fileToTensor(datasetObj.indexArr[trainPointer++])
  }
}

/**
 * @fileToTensorIter 将文件转换为张量的迭代器
 * @function 迭代器/生成器
 * @note 会读取datasetObj的indexArr、fileArr
 */
function* getValTensorIter() {
  // 指针
  let valPointer = 0
  // 生成器循环周期
  while (valPointer < datasetObj.valIndexArr.length) {
    // 用自增pionter从验证集字典数组获取索引，进而获得张量
    yield fileToTensor(datasetObj.valIndexArr[valPointer++])
  }
}

/**
 * @fileToTensor 文件转换为张量
 * @function
 * @param { Number } index 索引
 * @note 会读取datasetObj的imgElementArr、labelArr
 */
function fileToTensor(index) {
  return tf.tidy(() => {
    /**
     * @由img元素获取特征张量featureTensor
     */
    // 获取<img>元素
    const imgElement = datasetObj.imgElementArr[index]
    // 获取原始图片的张量
    const originTensor = tf.browser.fromPixels(imgElement)
    // 计算需要填充的量
    const padHeight = modelObj.inputHeight - imgElement.height
    const padWidth = modelObj.inputWidth - imgElement.width
    // 填充
    const paddedTensor = tf.pad(originTensor, [
      // 第一维度，即高度。除不尽的时候，上面少填充一个像素
      [Math.floor(padHeight / 2), Math.ceil(padHeight / 2)],
      // 第二维度，即宽度。除不尽的时候，左边少填充一个像素
      [Math.floor(padWidth / 2), Math.ceil(padWidth / 2)],
      // 第三维度，即通道数。不需要填充
      [0, 0]
    ])
    // 填充好的张量归一化（255像素值），即为特征张量featureTensor
    const featureTensor = paddedTensor.div(255.0)
    /**
     * @获取标签张量labelTensor
     */
    // 获取标签
    const label = datasetObj.labelArr[index]
    // 得到标签张量并归一化（减90度，除以90度，使接触角归一化为(-1, 1)）
    const labelTensor = tf.tensor1d([label]).sub(90).div(90.0)
    // 返回特征张量和标签张量
    return {
      xs: featureTensor,
      ys: labelTensor
    }
  })
}

/**
 * @indexArrShuffle 标签数组乱序
 * @function
 * @note 会读写datasetObj.indexArr
 */
function indexArrShuffle() {
  // Fisher-Yates洗牌算法：先倒序遍历
  for (let i = datasetObj.indexArr.length - 1; i > 0; i--) {
    // 然后再倒序轮到的数组范围内生成随机索引j
    const j = Math.floor(Math.random() * (i + 1))
    // i索引和j索引交换元素
    const temp = datasetObj.indexArr[i]
    datasetObj.indexArr[i] = datasetObj.indexArr[j]
    datasetObj.indexArr[j] = temp
  }
}

/**
 * @logTrainArr 训练过程记录
 * @function
 * @param { String } context 记录内容
 * @note 会读写logArrRef
 */
function logTrainArr(context) {
  if (logArrRef.value.length >= 10) {
    logArrRef.value.shift()
  }
  logArrRef.value.push(context)
}

</script>
